Repository navigation
fix(service-storage)!: an upload link is refused at issue for a file field whose readonlyWhen holds now (#32768) - #33079
Conversation
…ld whose readonlyWhen holds now ObjectQL gains judgeFieldWriteLock, a write-free verdict on whether a field takes a caller's write on a record now, built on the update's own readonlyWhen strip. createUploadLinkAs asks it for a file-field target and refuses FIELD_NOT_ACCESSIBLE 403 in the words the update's strict refusal uses, writing no link row. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czfTdXkf83P5ogS9TT
…32768) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czfTdXkf83P5ogS9TT
… tenant-audit census reads Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czfTdXkf83P5ogS9TT
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czfTdXkf83P5ogS9TT
📓 Docs Drift CheckThis PR changes 3 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3837bf7ffbe49e1c11df6ba421a61c2d621e4188 && git checkout 3837bf7ffbe49e1c11df6ba421a61c2d621e4188
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c4196099eee97f9fc48995063dde978fe5516a5d 506782945fd54b00d5ea4e4f8717d1049c23d03c && git checkout -B drift-repro c4196099eee97f9fc48995063dde978fe5516a5d && git merge --no-ff 506782945fd54b00d5ea4e4f8717d1049c23d03c
node scripts/docs-audit/affected-docs.mjs --json c4196099eee97f9fc48995063dde978fe5516a5d
|
|
domain:server#8 · os-elon-musk · session_016WV4cwqTK71kjQzHae8T5J Contract-review-claim: session_016WV4cwqTK71kjQzHae8T5J · head c3e09f3 · 2026-11-13T09:43Z |
|
domain:server#8 · os-elon-musk · session_016WV4cwqTK71kjQzHae8T5J Contract reviewServed-tier: Read at 2026-11-13T09:44Z: the diff against merge base ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
No must-fix. Implemented-by: VERDICT: PASS |
…'s droppedFields reason, for the upload link's readonlyWhen refusal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czfTdXkf83P5ogS9TT
|
domain:server#8 · os-elon-musk · session_016WV4cwqTK71kjQzHae8T5J Delta note (docs only) · head
|
|
domain:server#8 · os-elon-musk · session_016WV4cwqTK71kjQzHae8T5J ACCEPT · PR #33079 · head 席位核对
|
Requested by Elon · project thread
Fixes #32768
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no skill under
skills/**teachescreate_upload_linkor upload links; the tool's own description andcontent/docs/ai/connect-mcp.mdxcarry the refusal, updated here维护者速读
create_upload_link给用户要上传链接时,如果目标文件字段此刻被readonlyWhen锁住(例如报销单已审批后,发票字段只读),现在直接拒绝、不发链接,拒绝理由用的是更新接口对同一把锁的原话。引擎新增一个只读不写的判断judgeFieldWriteLock,复用更新路径自己的readonlyWhen判定,存储层只调用它,不另写一套表达式求值。FIELD_NOT_ACCESSIBLE,与静态只读字段同一个错误码。附件列表(不指定字段)不受影响;没有readonlyWhen的字段不多读一次库。回滚 = revert 本 PR,用户回到「上传后被拒」的旧体验,数据不受影响。packages/objectql/src/engine-field-write-lock.ts(判断本体,看它是否与更新路径同源);packages/services/service-storage/src/record-file-access.ts的createUploadLinkAs新增的那段拒绝;packages/objectql/src/engine.ts新方法judgeFieldWriteLock。pnpm --filter @objectstack/service-storage exec vitest run src/record-file-access.integration.test.ts,预期 36/36 通过,其中「minting refuses a field whose readonlyWhen holds now」一条绿。BREAKING for a caller that minted an upload link onto a file field while the field's
readonlyWhenheld.create_upload_linkanswered a link; the upload through it was refused when it arrived (403 FIELD_NOT_ACCESSIBLE, "Nothing was attached: …", since Upload links: a link is issued for a record the user cannot change, and the upload page then says "Done. The file was attached" when nothing was attached (readonlyWhen), or "Upload failed" with no reason (approval lock, 500) #32734), after the person had picked and sent the file.403 FIELD_NOT_ACCESSIBLE,You cannot attach a file to OBJECT.FIELD: the field is read-only in the target record's current state (a TRUE `readonlyWhen` predicate)., and no link row is written. The fix for a caller: mint the link once the record's state unlocks the field, or use the record's attachments, which carry no field lock and are unchanged.What changes
@objectstack/objectql—ObjectQL#judgeFieldWriteLock(object, id, field, { context }), the one write-free judgement of "is this field writable on this record now" for itsreadonlyWhenlock. Its body (engine-field-write-lock.ts) runs the update's own conditional strip,stripReadonlyWhenFields, over the update's own inputs: the stored row fromreadUpdatePriorRowasprevious, the master-detail header fromresolveMasterDetailParentunder the same gate (hasParentScopedReadonlyWhenInPayload), and a payload carrying the value the field holds now. It answersnull, or{ reason: 'readonly_when', phrase }: theDroppedFieldsEvent['reason']the update reports the field under, and the phraseReadonlyFieldRejectedErroruses for that reason (readonlyDropReasonPhrase, newly exported fromreadonly-strict-errors.ts, not from the package entry). A faulting predicate refuses as the write refuses; an unbound root holds the lock; a row the caller cannot read isRECORD_NOT_FOUND; a field with noreadonlyWhenreads nothing. The facade method inengine.tsis a delegation in the shapevalidateuses.FieldWriteLockis exported as a type.@objectstack/service-storage—createUploadLinkAs, for a file-field target, asksengine.judgeFieldWriteLockafter the staticreadonlyrefusal and before the approval lock, and refuses403 FIELD_NOT_ACCESSIBLEwith the engine's phrase. No CEL is evaluated here. The member is optional onRecordFileEngine: an engine without it is not asked, and the upload's own strict write still refuses on arrival. An attachments target (no field) is unchanged — it carries no field lock and is not judged.@objectstack/mcp— thecreate_upload_linkdescription names the new refusal.packages/mcp/src/file-content.tsis not touched: it only declares the bridge types; the issue-time refusals live inservice-storage(measured, and declared on the re-posted claim 6847846915).content/docs/ai/connect-mcp.mdx: which fields get no link, and with what answer.engine.validate,ValidateDataResponse) — not changed. Its published limit (readonlyWhenis not run) stays as written, so no published preview surface widens. The new judgement is a separate method; its only widening is the method itself.Tests
packages/objectql/src/engine-field-write-lock.test.ts(new, 7): locked with the update's reason and phrase; unlocked while the condition is false; aparent-scoped lock reads the header; each verdict cross-checked against whatengine.updatereports for the same field (onFieldsDropped,readonly_when); noreadonlyWhencosts no read; a faulting predicate refuses asValidationError; a missing row isRECORD_NOT_FOUND404; no driver write and no strip warning in any case.packages/services/service-storage/src/record-file-access.integration.test.ts(+2): minting on an approved claim'sinvoicesis refused 403 with the exact sentence and writes no link row; the same field on a draft gets a link, a staticreadonlyfield keeps its refusal word for word, and the attachments list of an approved claim still gets a link.packages/qa/dogfood/test/showcase-mcp-record-files.dogfood.test.ts(+1, the one dogfood pin, over the real MCP HTTP face): after approvalcreate_upload_linkis refused 403 with the engine's phrase and mints nothing, andupdate_recordon the same field answersdroppedFieldswith reasonreadonly_whennaming it.Acceptance notes
beforeUpdatehooks are not run (a hook that rewrites a field a predicate reads moves the write's verdict and not this one); a lock whose predicate reads the incoming value of its own field is judged against the value already stored.area:filesdoor) touches none of these files.Verification
All readings at head
c3e09f34unless named; the full dogfood battery ran atd9c65672, and the one commit after it (c3e09f34) changes only the objectql unit test's driver double (it applies the caller'slimit, forcheck:objectql-double-limit), re-run below.Package tests and typecheck (objectql, service-storage, mcp)
pnpm --filter @objectstack/objectql test— Test Files 594 passed (594), Tests 12784 passed (12784)pnpm --filter @objectstack/service-storage test— Test Files 79 passed (79), Tests 1486 passed (1486)pnpm --filter @objectstack/mcp test— Test Files 63 passed (63), Tests 1096 passed (1096)pnpm --filter @objectstack/objectql typecheck,@objectstack/service-storage typecheck,@objectstack/mcp typecheck— exit 0 each (after the dependency closure build)engine-field-write-lock.test.tsre-run atc3e09f34— Tests 7 passed (7)record-file-access.integration.test.tsatd9c65672— Tests 36 passed (36)Ablation of the issue-time refusal
Through
scripts/ablation-replace.mjsonpackages/services/service-storage/src/record-file-access.ts(committed head as the restore point): anchorrefuse('FIELD_NOT_ACCESSIBLE', 403, ... the field is ${lock.phrase}.)x1 -> x0, replacementvoid lock;x0 -> x1, bloba771bfae->d5ac8211.record-file-access.integration.test.tsunder the mutation: 1 failed | 35 passed, the failing oneminting refuses a field whose readonlyWhen holds now 403 FIELD_NOT_ACCESSIBLE, in the update's words, and writes no link. Restored: blob == HEAD (a771bfae),git diff HEADempty. The test runs the package's source, so nodist/leg.Full dogfood battery (Clause-② narrowing arm)
pnpm --filter @objectstack/dogfood testunder the verify lock atd9c65672: Test Files 458 passed | 3 skipped (461), Tests 3845 passed | 42 skipped (3887),VERDICT command-exit 0. The new pin inshowcase-mcp-record-files.dogfood.test.tsis among them (that file alone: 15/15).Derived gates and lint
node scripts/pm/dispatch-gates.mjs --commands(from the merge base, afterorigin/mainwas merged once at05853f3a): 104 families. Run record with exit codes:--ranreconciles 104 accounted, 101 run, 3 NOT MEASURED:node scripts/check-plugin-teardown-shape.mjs --self-test— exit 3, PREREQUISITE NOT MET: its positive control is a pinned commit a shallow clone cannot reach. NOT MEASURED, reason: shallow checkout; CI runs it.pnpm --filter @objectstack/spec run check:skill-examples— exit 3, PREREQUISITE NOT MET:packages/client-react/distnot built (outside this diff's closure). NOT MEASURED; CI runs it.pnpm check:dual-build-cjs-loads— exit 3, PREREQUISITE NOT MET: unbuilt packages outside this diff's closure (studio, client-react, scenarios, …). NOT MEASURED; CI runs it.Two reds found and fixed on this branch:
check:tenant-audit-census(theRecordFileEnginetype, widened as a type literal, stopped being an engine-shaped type the census reads; now an interface extendingIDataEngine, census back to 310) andcheck:objectql-double-limit(the new test's driver double ignoredlimit). Both exit 0 atc3e09f34, as docheck:engine-double-contractandcheck:nul-bytes.node scripts/check-platform-checklist.mjs --changed-from origin/main— OK, 21 anchors judged, 0 lost.pnpm lint— exit 0.Generated by Claude Code